Add SkyRL-TX Qwen SFT and RL example - #85
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Co-Authored-By: peyton@modal.com <pawalt@hey.com>
Co-Authored-By: peyton@modal.com <pawalt@hey.com>
Co-Authored-By: peyton@modal.com <pawalt@hey.com>
Co-Authored-By: peyton@modal.com <pawalt@hey.com>
Co-Authored-By: peyton@modal.com <pawalt@hey.com>
Co-Authored-By: peyton@modal.com <pawalt@hey.com>
Co-Authored-By: peyton@modal.com <pawalt@hey.com>
Co-Authored-By: peyton@modal.com <pawalt@hey.com>
| cmd = [ | ||
| "uv", | ||
| "run", | ||
| "--no-sync", | ||
| "--extra", | ||
| "gpu", | ||
| "--extra", | ||
| "tinker", | ||
| "--extra", | ||
| "jax", | ||
| "-m", | ||
| "skyrl.backends.jax", | ||
| "--coordinator-address", | ||
| f"{master_addr}:{COORDINATOR_PORT}", | ||
| "--num-processes", | ||
| str(n_nodes), | ||
| "--process-id", | ||
| str(rank), | ||
| ] | ||
| with log_path.open("wb") as log_file: | ||
| process = subprocess.Popen( | ||
| cmd, | ||
| cwd=SKYRL_ROOT, | ||
| stdout=log_file, | ||
| stderr=subprocess.STDOUT, | ||
| start_new_session=True, | ||
| ) |
There was a problem hiding this comment.
🚩 Coordinator startup ordering relies on JAX retry semantics
Workers (rank > 0) start their JAX backend process immediately at modal_train.py:160, potentially before rank 0 has even started the API server / JAX coordinator. The JAX backend workers connect to --coordinator-address and must internally retry until the coordinator is reachable. If the JAX backend does not implement connection retries, workers would fail immediately. This appears to work correctly with SkyRL-TX's JAX backend (which uses JAX's distributed initialization that retries coordinator connections), but it's worth verifying if the SkyRL commit is ever updated.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Confirmed this was a concrete ordering robustness issue. I pushed b2c3abc, which gates rank>0 worker startup on a Modal Dict signal written only after rank 0 has spawned the coordinator/API process, so workers no longer launch before the coordinator process exists.
There was a problem hiding this comment.
This is outdated on the current branch: _run_worker() now calls _wait_for_coordinator_launch() before spawning the JAX backend process, and rank 0 only writes run_state[run_key] = "running" after the API/coordinator subprocess has been started. So the current code no longer depends on JAX retry semantics for the rank>0 startup ordering.
There was a problem hiding this comment.
Addressed in the current branch. Rank >0 now calls _wait_for_coordinator_launch() before spawning skyrl.backends.jax, and rank 0 sets run_state[run_key] = "running" only after starting the API/coordinator process. I also added a SkyRL RPC barrier patch so workers and coordinator enter subsequent multihost calls in the same order.
There was a problem hiding this comment.
Update: I removed the experimental RPC barrier patch while narrowing the checkpoint fix. The concrete startup-ordering issue in this thread remains addressed by the run_state gate: rank >0 waits for rank 0 to mark the coordinator process as running before it launches skyrl.backends.jax.
|
Runtime validation completed against real Modal H100:8 clusters. Commandsuv run modal run skyrl-tx/modal_train.py::run_sft --steps 2 --lora-rank 16
uv run modal run skyrl-tx/modal_train.py::run_rl --steps 2 --samples-per-prompt 2 --lora-rank 16SFT passedModal run: https://modal.com/apps/modal-labs/peyton-agents/ap-LkvYEPsg7S75IMzqdeRIc2 RL passedModal run: https://modal.com/apps/modal-labs/peyton-agents/ap-8XtD4CvG9DNYHfYmXdtN9q Static checks also passed: |
| checkpoint_files = sorted(checkpoint_root.rglob("*.tar.gz")) | ||
| if not checkpoint_files: | ||
| raise RuntimeError( | ||
| f"No {mode} checkpoints were written under {checkpoint_root}" | ||
| ) |
There was a problem hiding this comment.
🚩 _commit_and_print_checkpoints depends on .tar.gz format assumption
At modal_train.py:286, _commit_and_print_checkpoints searches for *.tar.gz files under the checkpoint root. If the SkyRL-TX Tinker API server saves checkpoints in a different format (e.g., directory-based Orbax checkpoints or SafeTensors), this glob would find nothing and raise a RuntimeError. The _patch_skyrl_checkpointing function patches the backend to use Orbax, whose native format is directory-based, not .tar.gz. However, the Tinker SDK's save_state and save_weights_for_sampler methods likely wrap this into their own archive format. The PR's README shows successful results with .tar.gz checkpoint files, so this presumably works, but the coupling to a specific archive format is fragile.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Acknowledged. This is intentionally scoped to the pinned SkyRL commit (830f005...), whose Tinker checkpoint writer produced .tar.gz artifacts in both validated Modal runs. If the SkyRL pin is updated and the checkpoint format changes, this helper should be updated with that pin bump.
There was a problem hiding this comment.
Fixed in c27f75b: _commit_and_print_checkpoints now uses _checkpoint_artifacts(...), which prefers the validated .tar.gz archive outputs but falls back to any non-results checkpoint files under the run-scoped checkpoint root. The log line is now *_checkpoint_artifact=... so future directory/file checkpoint formats won't false-fail just because they are not .tar.gz archives.
| if run_state.get(run_key) == "done": | ||
| _terminate_process_group(process) | ||
| checkpoint_volume.commit() | ||
| print( |
There was a problem hiding this comment.
🚩 Worker checkpoint_volume.commit() after rank 0 has already committed
Workers call checkpoint_volume.commit() at skyrl-tx/modal_train.py:236 after seeing rank 0's "done" signal. Rank 0 already committed the volume at line 290 (inside _commit_and_print_checkpoints). With the patched Orbax checkpointer using primary_host=None, all JAX processes participate in writing checkpoints. If workers write their portion of a multi-process checkpoint to the volume mount, those writes must also be committed from the worker containers. The worker commit after rank 0's commit ensures this. However, there may be a brief window where the volume is in a partially-committed state between rank 0's commit and workers' commits.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
I checked this path against the current control flow and the validated Modal runs. _run_client(...) only returns after the server-side save calls finish, so the worker-side checkpoint writes have completed before rank 0 enters _commit_and_print_checkpoints(...). Rank 0 then commits what it can see, sets run_state[run_key] = "done" in finally, and the worker containers each commit their own mounted-volume writes before returning. The clustered Modal call does not complete until those worker containers return, so the post-run durable volume state includes the worker commits.
I agree there can be a transient live-log window between rank 0’s commit log line and the worker commit calls, but that window is before the clustered job has completed and before a follow-up run should consume the volume. The final SFT/RL validation runs both restored/evaluated sampler checkpoints after save, so I’m treating this as a maintenance note rather than a current functional bug.
Summary
Adds a
skyrl-tx/example that runs SkyRL-TX as a Tinker-compatible server on a Modal clustered GPU job forQwen/Qwen3-8B, plus runnable compatibility smoke coverage for 10 public Tinker cookbook recipes.The example includes:
run_sft: launches clustered SkyRL-TX on 2 fullH100:8nodes, trains a LoRA adapter with Tinkercross_entropy, saves training-state and sampler checkpoints, evaluates the trained adapter, reloads the saved sampler checkpoint, samples from it, and commits checkpoint artifacts to a Modal Volume.run_rl: uses the same clustered Tinker server pattern for a small PPO loop over sampled arithmetic rollouts, saves training-state and sampler checkpoints, runs a post-train PPO eval pass, reloads the saved sampler checkpoint for reward eval, and commits checkpoint artifacts.run_cookbook: launches the same real SkyRL-TX training server and runscookbook_smoke_client.py, a 10-example smoke suite that exercises cookbook-style SFT, RL, chat SFT, math RL, code RL, DPO, RLHF, on-policy distillation, search-tool transcripts, and VLM API input handling. Omit--exampleto run the full suite, or pass--example <name>to run a single probe.H100:8nodes because partial H100 allocations do not work for multi-node SkyRL-TX.--no-syncsubprocess launches, real/api/v1/healthzreadiness polling, conservative Adam defaults (lr=1e-6,beta1=0.9,beta2=0.95,eps=1e-8), finite metric validation, result-only cookbook smoke handling, and a runtime SkyRL Orbax patch that treats each Modal Volume mount as local storage for multiprocess checkpoint saves./checkpoints/<mode>/<cluster_id>-<mode>so validation output lists only artifacts from the current Modal run.skyrl-tx/cookbook-*/README.md, now updated with executed smoke results, per-example smoke commands, and caveats about external dependencies like sandboxes, Chroma retrieval, separate teacher models, and real VLM rendering.Validated against real Modal runs (not CI) on 2×
H100:8:Runtime evidence:
cookbook_results.jsonl, and committed checkpoint artifacts: https://modal.com/apps/modal-labs/peyton-agents/ap-XXR2RSArZPeVYNFL66rwjt--example chat_slpassed, wrote onlycookbook_results.jsonl, and committed the result-only volume path without checkpoint artifacts: https://modal.com/apps/modal-labs/peyton-agents/ap-nYhOLRkGvYiz1gUE1hsa05The cookbook smoke pass means the listed Tinker API primitives ran end-to-end on SkyRL-TX. The reports still mark recipes as partial where the full public cookbook recipe needs non-SkyRL infrastructure or model changes: code sandboxing, Chroma retrieval, separate teacher models, full preference datasets, or true Qwen3-VL image conditioning.
Checklist
latestpython_versionfor the base image, if it is used~=x.y.zor==x.yversion < 1are pinned to patch version,==0.y.z(Modal's internal guide page for this repo is Multi-node examples guidance.)
Outside contributors
You're great! Thanks for your contribution.
Link to Devin session: https://modal.devinenterprise.com/sessions/493dccb1bbb94ddaa8f752529c2f80c2
Requested by: @pawalt